"""Route webhook messages to an amqp queue."""
import collections
import hashlib
import hmac
import json
import os
import re
import time
from urllib import parse

from cki_lib import logger
from cki_lib import messagequeue
from cki_lib import misc

LOGGER = logger.get_logger(__name__)

RABBITMQ_EXCHANGE = os.environ.get(
    'RABBITMQ_EXCHANGE', 'cki.exchange.webhooks')
RABBITMQ_GITLAB_TOPIC_TEMPLATE = os.environ.get(
    'RABBITMQ_GITLAB_TOPIC_TEMPLATE', '{web_url.hostname}.{web_url.path}.{object_kind}')
RABBITMQ_KEEPALIVE_S = misc.get_env_int('RABBITMQ_KEEPALIVE_S', 60)

QUEUE = messagequeue.MessageQueue(keepalive_s=RABBITMQ_KEEPALIVE_S)

WAITING = collections.deque()

TRY_ENDLESSLY = False


def lowercase_headers(headers):
    """Return headers with all keys in lowercase.

    HTTP headers are case-insensitive.
    """
    return {k.lower(): v for k, v in headers.items()}


def isoformat_http_date(headers: dict[str, str]) -> str:
    # pylint: disable=bare-except
    """Return the HTTP header's 'date' value in ISO 8601 format, or '' if it cannot be parsed."""
    # Despite its name, datetime_fromisoformat_tz_utc() is using dateutil.parse and will happily
    # consume the http header date format.
    try:
        return misc.datetime_fromisoformat_tz_utc(headers.get('date', '')).isoformat()
    except:  # noqa: E722
        return ''


def check_websecret(secret):
    """Check the webhook websecret token.

    Returns None if the check is successful.
    """
    key = os.environ.get('WEBHOOK_RECEIVER_WEBSECRET')
    if not key:
        return (511, 'Permission denied: No token configured')
    if not secret:
        return (511, 'Permission denied: missing webhook token')

    if secret != key:
        return (511, 'Permission denied: invalid webhook token')

    return None


def gitlab_handler(headers, body: bytes, **_):
    """Process a webhook."""
    headers = lowercase_headers(headers)
    check_result = check_websecret(headers.get("x-gitlab-token"))
    if check_result:
        return check_result

    data = json.loads(body)
    if 'object_kind' not in data:
        return (200, 'Ignoring message with missing object_kind')
    object_kind = data['object_kind']
    if 'project' in data:
        web_url = data['project']['web_url']
    else:
        web_url = data['repository']['homepage']
    web_url = parse.urlsplit(web_url)
    topic = re.sub('[./]+', '.', RABBITMQ_GITLAB_TOPIC_TEMPLATE.format(
        web_url=web_url,
        object_kind=object_kind,
        env=os.environ))

    amqp_headers = {'message-type': 'gitlab'} | {
        f'message-gitlab-{header}': value
        for header in ('event', 'event-uuid', 'instance', 'webhook-uuid')
        if (value := headers.get(f'x-gitlab-{header}'))
    }
    if date_str := isoformat_http_date(headers):
        amqp_headers['message-date'] = date_str

    enqueue_and_send(data, topic, amqp_headers)
    return (200, 'OK')


def check_sentry_signature(headers, body: bytes):
    """Check the Sentry hook signature."""
    key = os.environ.get('WEBHOOK_RECEIVER_SENTRY_IO_CLIENT_SECRET')
    if not key:
        return (511, 'Permission denied: No token configured')
    try:
        signature = headers['sentry-hook-signature']
    except KeyError:
        return (511, 'Permission denied: missing Sentry hook signature')

    calculated = hmac.new(
        key=key.encode('utf8'),
        msg=body,
        digestmod=hashlib.sha256,
    ).hexdigest()

    if calculated != signature:
        return (511, 'Permission denied: invalid Sentry hook signature')

    return None


def sentry_handler(headers, body: bytes, **_):
    """Process a webhook."""
    headers = lowercase_headers(headers)
    check_result = check_sentry_signature(headers, body)
    if check_result:
        return check_result

    data = json.loads(body)

    resource = headers['sentry-hook-resource']
    if resource == 'issue':
        project = data['data']['issue']['project']['slug']
        action = data['action']
        topic = f'sentry.io.{project}.{resource}.{action}'
    elif resource == 'event_alert':
        project = re.sub('.*/([^/]+)/events/.*', r'\1',
                         data['data']['event']['url'])
        action = data['action']
        topic = f'sentry.io.{project}.{resource}.{action}'
    else:
        LOGGER.info('Ignoring resource %s', resource)
        return (200, f'Ignoring {resource}')

    amqp_headers = {
        'message-type': 'sentry',
        'message-sentry-resource': resource,
    }
    if date_str := isoformat_http_date(headers):
        amqp_headers['message-date'] = date_str

    enqueue_and_send(data, topic, amqp_headers)
    return (200, 'OK')


def jira_handler(headers, body: bytes, request_args):
    """Process a JIRA webhook."""
    headers = lowercase_headers(headers)
    check_result = check_websecret(request_args.get("token"))
    if check_result:
        return check_result

    # Some events (issuelink_created!) do not have any project or host identifier.
    hostname = 'issues.redhat.com' if not misc.is_staging() else 'issues.stage.redhat.com'
    project_key = 'UNKNOWN'

    data = json.loads(body)

    # We used to use `issue_event_type_name` but not all events have it (like issuelink_created) so
    # instead use `webhookEvent` as it seems to be the standard value.
    # https://developer.atlassian.com/server/jira/platform/webhooks/#configuring-a-webhook
    event_type = re.sub('^_|_$', '',                         # no underscores at end or beginning
                        re.sub('_{2,}', '_',                 # no consecutive underscores
                               re.sub('[^A-Za-z]', '_',      # replace non-letter by underscore
                                      data.get('webhookEvent', 'unknown'  # deal with weird messages
                                               ).lower())))  # routing keys are all lower-case

    if 'issue' in data:
        hostname = parse.urlsplit(misc.get_nested_key(data, 'issue/self')).hostname
        project_key = misc.get_nested_key(data, 'issue/fields/project/key')

    topic = f'{hostname}.{project_key}.{event_type}'

    amqp_headers = {'message-type': 'jira'}
    if date_str := isoformat_http_date(headers):
        amqp_headers['message-date'] = date_str

    enqueue_and_send(data, topic, amqp_headers)
    return (200, 'OK')


def enqueue_and_send(data, topic, headers):
    """Enqueue message and try to send queue."""
    has_retried = False
    WAITING.append((data, topic))
    while WAITING:
        data, topic = WAITING.popleft()
        try:
            if misc.is_production_or_staging():
                LOGGER.info('Sending webhook payload to %s', topic)
                QUEUE.send_message(data, topic,
                                   exchange=RABBITMQ_EXCHANGE,
                                   headers=headers)
            else:
                LOGGER.info('Not sending because not production/staging: %s: %s - %s',
                            topic, headers, data)
        # pylint: disable=broad-except
        except Exception:
            WAITING.appendleft((data, topic))
            # retry once without being noisy; if the connection had been closed
            # by the server, just trying again might be all that is needed
            if has_retried:
                LOGGER.exception('Error during sending message, will be retried')
                if TRY_ENDLESSLY:
                    time.sleep(1)
                else:
                    return
            else:
                has_retried = True
